package com.dbflow5.observing;

import com.dbflow5.StringUtils;
import com.dbflow5.config.DBFlowDatabase;
import com.dbflow5.config.FlowLog;
import com.dbflow5.config.FlowManager;
import com.dbflow5.database.DatabaseStatement;
import com.dbflow5.database.DatabaseWrapper;
import com.dbflow5.database.FlowCursor;
import com.dbflow5.database.SQLiteException;
import com.dbflow5.query.TriggerMethod;
import com.dbflow5.transaction.Transaction;

import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.Lock;
import java.util.function.Function;

/**
 * Description: Tracks table changes in the DB via Triggers. This more efficient than utilizing
 * in the app space.
 */
public class TableObserver {

    private static final String TABLE_OBSERVER_NAME = "dbflow_table_log";
    private static final String TRIGGER_PREFIX = "dbflow_table_trigger";
    private static final String INVALIDATED_COLUMN_NAME = "invalidated";
    private static final String TABLE_ID_COLUMN_NAME = "table_id";
    private static final String SELECT_UPDATED_TABLES_SQL = "SELECT * FROM " + TABLE_OBSERVER_NAME + " WHERE " + INVALIDATED_COLUMN_NAME + " = 1;";

    private final DBFlowDatabase db;
    private final List<Class<?>> tables;

    public TableObserver(DBFlowDatabase db, List<Class<?>> tables) {
        this.db = db;
        this.tables = tables;

        observingTableTracker = new ObservingTableTracker(tables.size());
        tableStatus = new boolean[tables.size()];
        init();
    }

    private final Map<Class<?>, Integer> tableReferenceMap = new HashMap<>();
    private final Map<Integer, Class<?>> tableIndexToNameMap = new HashMap<>();

    private final ObservingTableTracker observingTableTracker;
    private final Map<OnTableChangedObserver, OnTableChangedObserver.OnTableChangedObserverWithIds> observerToObserverWithIdsMap = new HashMap<>();

    private final boolean[] tableStatus;

    private boolean initialized = false;

    private final AtomicBoolean pendingRefresh = new AtomicBoolean(false);

    private DatabaseStatement cleanupStatement() {
        return db.compileStatement("UPDATE " + TABLE_OBSERVER_NAME + " SET " + INVALIDATED_COLUMN_NAME + " = 0 WHERE " + INVALIDATED_COLUMN_NAME + " = 1;");
    }

    private void init() {
        for (int index = 0; index < tables.size(); index++) {
            Class<?> name = tables.get(index);
            tableReferenceMap.put(name, index);
            tableIndexToNameMap.put(index, name);
        }
    }

    public void addOnTableChangedObserver(OnTableChangedObserver observer) {
        int[] newTableIds = new int[observer.tables.size()];

        for (int index = 0; index < observer.tables.size(); index++) {
            Class<?> table = observer.tables.get(index);
            int id = tableReferenceMap.get(table);
            if (id >= 0) {
                newTableIds[index] = id;
            } else {
                throw new IllegalArgumentException("No Table found for " + table);
            }
        }
        OnTableChangedObserver.OnTableChangedObserverWithIds wrapped = new OnTableChangedObserver.OnTableChangedObserverWithIds(observer, newTableIds);
        synchronized (observerToObserverWithIdsMap) {
            if (!observerToObserverWithIdsMap.containsKey(observer)) {
                observerToObserverWithIdsMap.put(observer, wrapped);

                if (observingTableTracker.onAdded(newTableIds)) {
                    syncTriggers();
                }
            }
        }
    }

    public void removeOnTableChangedObserver(OnTableChangedObserver observer) {
        synchronized (observerToObserverWithIdsMap) {
            OnTableChangedObserver.OnTableChangedObserverWithIds tableObserver = observerToObserverWithIdsMap.remove(observer);
            if (observingTableTracker.onRemoved(tableObserver.tableIds)) {
                syncTriggers();
            }
        }
    }

    /**
     * Enqueues a table update check on the [DBFlowDatabase] Transaction queue.
     */
    public void enqueueTableUpdateCheck() {
        if (!pendingRefresh.compareAndSet(false, true)) {
            Transaction.Builder<Object> builder = db.beginTransactionAsync((Function<DatabaseWrapper, Object>) databaseWrapper -> {
                // TODO: ugly cast check here.
                if (databaseWrapper == db) {
                    checkForTableUpdates((DBFlowDatabase)databaseWrapper);
                } else {
                    throw new RuntimeException("Invalid DB object passed. Must be a " + DBFlowDatabase.class);
                }
                return null;
            });
            builder.shouldRunInTransaction(false).execute(null,(transaction, throwable) -> {
                FlowLog.log(FlowLog.Level.E, "Could not check for table updates", (Throwable) throwable);
                return null;
            },null,null);
        }
    }

    public void checkForTableUpdates() {
        syncTriggers();
        checkForTableUpdates(db);
    }

    public void construct(DatabaseWrapper db) {
        synchronized (this) {
            if (initialized) {
                FlowLog.log(FlowLog.Level.W, "TableObserver already initialized");
                return;
            }

            db.executeTransaction(unused -> {
                db.execSQL("PRAGMA temp_store = MEMORY;");
                db.execSQL("PRAGMA recursive_triggers=ON;");
                db.execSQL("CREATE TEMP TABLE "+TABLE_OBSERVER_NAME+"("+TABLE_ID_COLUMN_NAME+" INTEGER PRIMARY KEY, "+INVALIDATED_COLUMN_NAME+" INTEGER NOT NULL DEFAULT 0);");
                return null;
            });

            syncTriggers(db);
            initialized = true;
        }
    }

    public void syncTriggers() {
        if (db.isOpened()) {
            syncTriggers(db);
        }
    }

    public void syncTriggers(DatabaseWrapper db) {
        if (db.isInTransaction()) {
            // don't run in another transaction.
            return;
        }

        try {
            while (true) {
                Lock lock = this.db.getCloseLock();
                lock.lock();
                try {
                    ObservingTableTracker.Operation[] tablesToSync = observingTableTracker.tablesToSync();
                    if (tablesToSync == null) {
                        return;
                    }
                    db.executeTransaction(unused -> {
                        for (int index = 0; index < tablesToSync.length; index++) {
                            ObservingTableTracker.Operation operation = tablesToSync[index];
                            switch (operation) {
                                case Add:
                                    observeTable(db, index);
                                    break;
                                case Remove:
                                    stopObservingTable(db, index);
                                    break;
                                case None:
                                    // don't do anything
                                    break;
                                default:
                                    break;
                            }
                        }
                        return null;
                    });
                    observingTableTracker.syncCompleted();
                } finally {
                    lock.unlock();
                }
            }
        } catch (Exception e) {
            if (e instanceof IllegalStateException || e instanceof SQLiteException) {
                FlowLog.log(FlowLog.Level.E, "Cannot sync table TRIGGERs. Is the db closed?", e);
            } else {
                throw e;
            }
        }
    }

    public void checkForTableUpdates(DBFlowDatabase db) {
        Lock lock = db.getCloseLock();
        boolean hasUpdatedTable = false;

        try {
            lock.lock();

            if (!db.isOpened()) {
                return;
            }

            if (!initialized) {
                db.openHelper().database();
            }
            if (!initialized) {
                FlowLog.log(FlowLog.Level.E, "Database is not initialized even though open. Is this an error?");
                return;
            }

            if (!pendingRefresh.compareAndSet(true, false)) {
                return;
            }

            if (db.isInTransaction()) {
                //return;
            }

            hasUpdatedTable = checkUpdatedTables();
        } catch (Exception e) {
            if (e instanceof IllegalStateException || e instanceof SQLiteException) {
                FlowLog.log(FlowLog.Level.E, "Cannot check for table updates. is the db closed?", e);
            } else {
                throw e;
            }
        } finally {
            lock.unlock();
        }
        if (hasUpdatedTable) {
            synchronized (observerToObserverWithIdsMap) {
                Collection<OnTableChangedObserver.OnTableChangedObserverWithIds> list = observerToObserverWithIdsMap.values();
                for (OnTableChangedObserver.OnTableChangedObserverWithIds observer : list) {
                    observer.notifyTables(tableStatus);
                }
            }
            // reset
            Arrays.fill(tableStatus, false);
        }
    }

    private boolean checkUpdatedTables() {
        boolean hasUpdatedTable = false;
        FlowCursor cursor = db.rawQuery(SELECT_UPDATED_TABLES_SQL, null);
        while (cursor.goToNextRow()) {
            int tableId = cursor.getInt(0);
            tableStatus[tableId] = true;
            hasUpdatedTable = true;
        }
        if (hasUpdatedTable) {
            cleanupStatement().executeUpdateDelete();
        }
        return hasUpdatedTable;
    }

    private void observeTable(DatabaseWrapper db, int tableId) {
        db.execSQL("INSERT OR IGNORE INTO "+TABLE_OBSERVER_NAME+" VALUES("+tableId+", 0)");
        Class<?> tableName = tables.get(tableId);

        for (String method : TriggerMethod.METHODS){
            // utilize raw query, since we're using dynamic tables not supported by query language.
            db.execSQL("CREATE TEMP TRIGGER IF NOT EXISTS " + getTriggerName(tableName, method) +
                    "AFTER " + method+ " ON " + StringUtils.quoteIfNeeded(FlowManager.getTableName(tableName)) + " BEGIN UPDATE " + TABLE_OBSERVER_NAME + " " +
                    "SET " + INVALIDATED_COLUMN_NAME + " = 1 " +
                    "WHERE " + TABLE_ID_COLUMN_NAME + " = " + tableId + " " +
                    "AND " + INVALIDATED_COLUMN_NAME + " = 0; END");
        }

    }

    private void stopObservingTable(DatabaseWrapper db, int tableId) {
        Class<?> tableName = tables.get(tableId);

        for (String method : TriggerMethod.METHODS){
            db.execSQL("DROP TRIGGER IF EXISTS " + getTriggerName(tableName, method));
        }
    }

    private String getTriggerName(Class<?> table, String method) {
        String str = StringUtils.stripQuotes(FlowManager.getTableName(table));
        return "" + TRIGGER_PREFIX + "_" + str + "_" + method + "";
    }
}